Skip to content

[android] fix talkback on pressable#4017

Open
akwasniewski wants to merge 11 commits intomainfrom
@akwasniewski/android-fix-talback
Open

[android] fix talkback on pressable#4017
akwasniewski wants to merge 11 commits intomainfrom
@akwasniewski/android-fix-talback

Conversation

@akwasniewski
Copy link
Contributor

@akwasniewski akwasniewski commented Mar 6, 2026

Description

When TalkBack is activated, pressable cannot be clicked on android. This PR fixes the issue.

The reason was that Pressable requires a specific order of events. This order of events is hardcoded and found empirically. However, with talback enabled the order of events is different, I added a module function which queries native side whether or not talback is enabled and adjusts expected event order accordingly.
Moreover with talback enabled longPress gets both onPointerUp and onPointerDown before native side receives its events, thus we must not reset the state machine state on onPointerUp.

Test plan

Tested on the following example:

Details
import React from 'react';
import { Text, StyleSheet, View } from 'react-native';
import { Pressable, GestureHandlerRootView } from 'react-native-gesture-handler'

const PressableExample = () => {
  const [count, setCount] = React.useState(0);
  return (
    <GestureHandlerRootView style={styles.container}>
      <Pressable onPress={() => setCount((c) => c + 1)}>
        <View style={styles.pressable}>
          <Text>{count}</Text>
        </View>
      </Pressable>
    </GestureHandlerRootView >
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  pressable: {
    alignItems: 'center', justifyContent: 'center', backgroundColor: 'green', width: 100, height: 100
  },
  wrapperCustom: {
    borderRadius: 8,
    padding: 16,
    minWidth: 150,
    alignItems: 'center',
  },
  text: {
    fontSize: 18,
    color: 'white',
    fontWeight: '600',
  },
});

export default PressableExample;

Copilot AI review requested due to automatic review settings March 6, 2026 12:09
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes Android TalkBack interactions with Pressable by adapting the internal Pressable state machine to the different event ordering produced when accessibility services are active.

Changes:

  • Added a new native module API (isAccessibilityEnabled) to detect accessibility state from JS.
  • Adjusted Android Pressable state machine event ordering when accessibility is enabled.
  • Prevented an Android-only state-machine reset on onTouchesUp when accessibility is enabled (to avoid breaking long-press flows under TalkBack).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/v3/components/Pressable.tsx Skips the Android onTouchesUp reset path when accessibility is enabled.
packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts Extends the TurboModule spec with isAccessibilityEnabled(): boolean.
packages/react-native-gesture-handler/src/components/Pressable/stateDefinitions.ts Adds an Android accessibility-specific state config and selects it when accessibility is enabled.
packages/react-native-gesture-handler/src/RNGestureHandlerModule.web.ts Adds a web stub for isAccessibilityEnabled (currently unimplemented).
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerModule.kt Implements isAccessibilityEnabled() on Android via AccessibilityManager.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +261 to +264
if (
Platform.OS === 'android' &&
!RNGestureHandlerModule.isAccessibilityEnabled()
) {
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls the native isAccessibilityEnabled() synchronously on every onTouchesUp. Since this value typically changes rarely, consider reading it once (e.g., on mount) and caching it in a ref/state to avoid repeated sync native calls on a hot interaction path.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 134 to 138
if (Platform.OS === 'android') {
if (isScreenReaderEnabled()) {
return getAndroidAccessibilityStatesConfig(handlePressIn, handlePressOut);
}
return getAndroidStatesConfig(handlePressIn, handlePressOut);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStatesConfig selects the Android state-machine configuration based on the screen reader status at the time this function is called. In both Pressable implementations, the state machine config is set once in a useEffect that does not re-run when the screen reader status changes, so toggling TalkBack while the app is running can leave Pressable using the wrong event-order config until remount. Consider wiring screen-reader status as reactive state (subscribe in the component and re-run setStates when it changes) or making getStatesConfig accept the current status as a parameter from the component.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +106 to +110
export function isScreenReaderEnabled(): boolean {
if (isScreenReaderEnabledCache === null) {
isScreenReaderEnabledCache = RNGestureHandlerModule.isScreenReaderEnabled();
}
return isScreenReaderEnabledCache;
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isScreenReaderEnabled() calls RNGestureHandlerModule.isScreenReaderEnabled() on first use, but the Jest mock used in jestSetup.js (src/mocks/module.tsx) doesn’t currently define this new method. Any tests (or consumer test code) that call into this utility (directly or via Pressable on Android) will throw TypeError: ... isScreenReaderEnabled is not a function. Add a default mock implementation (e.g. returning false) to the mocked module to keep the test environment consistent with the new public API.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +59 to +61
isScreenReaderEnabled() {
// NO-OP
},
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isScreenReaderEnabled() is declared to return a boolean but this Windows stub currently returns undefined (no return statement). That can break callers like isScreenReaderEnabled() in src/utils.ts (cache becomes undefined) and may cause incorrect branching. Return an explicit boolean (likely false) to keep behavior predictable on Windows.

Copilot uses AI. Check for mistakes.
@akwasniewski akwasniewski requested a review from m-bert March 9, 2026 17:12
@akwasniewski akwasniewski marked this pull request as ready for review March 11, 2026 09:09
@akwasniewski akwasniewski requested a review from j-piasecki March 11, 2026 09:09
Copy link
Member

@j-piasecki j-piasecki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried to approach this from the other side? i.e., why are the events in a different order when talkback is enabled? Maybe it's a problem with the native implementation (especially since the changes seems targeted to Android)

let isScreenReaderEnabledCache: boolean | null = null;

AccessibilityInfo.addEventListener('screenReaderChanged', () => {
isScreenReaderEnabledCache = RNGestureHandlerModule.isScreenReaderEnabled();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need a new method in the module when there's one in React Native, and you can also just get the value from the event here?

Comment on lines +136 to +141
val accessibilityManager = reactApplicationContext.getSystemService(
Context.ACCESSIBILITY_SERVICE,
) as AccessibilityManager?

return accessibilityManager?.isTouchExplorationEnabled ?: false
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we already have this defined in some utils or extensions file?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants